@aaronshaf/confluence-cli 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/package.json +73 -0
  4. package/src/cli/commands/attachments.ts +113 -0
  5. package/src/cli/commands/clone.ts +188 -0
  6. package/src/cli/commands/comments.ts +56 -0
  7. package/src/cli/commands/create.ts +58 -0
  8. package/src/cli/commands/delete.ts +46 -0
  9. package/src/cli/commands/doctor.ts +161 -0
  10. package/src/cli/commands/duplicate-check.ts +89 -0
  11. package/src/cli/commands/file-rename.ts +113 -0
  12. package/src/cli/commands/folder-hierarchy.ts +241 -0
  13. package/src/cli/commands/info.ts +56 -0
  14. package/src/cli/commands/labels.ts +53 -0
  15. package/src/cli/commands/move.ts +23 -0
  16. package/src/cli/commands/open.ts +145 -0
  17. package/src/cli/commands/pull.ts +241 -0
  18. package/src/cli/commands/push-errors.ts +40 -0
  19. package/src/cli/commands/push.ts +699 -0
  20. package/src/cli/commands/search.ts +62 -0
  21. package/src/cli/commands/setup.ts +124 -0
  22. package/src/cli/commands/spaces.ts +42 -0
  23. package/src/cli/commands/status.ts +88 -0
  24. package/src/cli/commands/tree.ts +190 -0
  25. package/src/cli/help.ts +425 -0
  26. package/src/cli/index.ts +413 -0
  27. package/src/cli/utils/browser.ts +34 -0
  28. package/src/cli/utils/progress-reporter.ts +49 -0
  29. package/src/cli.ts +6 -0
  30. package/src/lib/config.ts +156 -0
  31. package/src/lib/confluence-client/attachment-operations.ts +221 -0
  32. package/src/lib/confluence-client/client.ts +653 -0
  33. package/src/lib/confluence-client/comment-operations.ts +60 -0
  34. package/src/lib/confluence-client/folder-operations.ts +203 -0
  35. package/src/lib/confluence-client/index.ts +47 -0
  36. package/src/lib/confluence-client/label-operations.ts +102 -0
  37. package/src/lib/confluence-client/page-operations.ts +270 -0
  38. package/src/lib/confluence-client/search-operations.ts +60 -0
  39. package/src/lib/confluence-client/types.ts +329 -0
  40. package/src/lib/confluence-client/user-operations.ts +58 -0
  41. package/src/lib/dependency-sorter.ts +233 -0
  42. package/src/lib/errors.ts +237 -0
  43. package/src/lib/file-scanner.ts +195 -0
  44. package/src/lib/formatters.ts +314 -0
  45. package/src/lib/health-check.ts +204 -0
  46. package/src/lib/markdown/converter.ts +427 -0
  47. package/src/lib/markdown/frontmatter.ts +116 -0
  48. package/src/lib/markdown/html-converter.ts +398 -0
  49. package/src/lib/markdown/index.ts +21 -0
  50. package/src/lib/markdown/link-converter.ts +189 -0
  51. package/src/lib/markdown/reference-updater.ts +251 -0
  52. package/src/lib/markdown/slugify.ts +32 -0
  53. package/src/lib/page-state.ts +195 -0
  54. package/src/lib/resolve-page-target.ts +33 -0
  55. package/src/lib/space-config.ts +264 -0
  56. package/src/lib/sync/cleanup.ts +50 -0
  57. package/src/lib/sync/folder-path.ts +61 -0
  58. package/src/lib/sync/index.ts +2 -0
  59. package/src/lib/sync/link-resolution-pass.ts +139 -0
  60. package/src/lib/sync/sync-engine.ts +681 -0
  61. package/src/lib/sync/sync-specific.ts +221 -0
  62. package/src/lib/sync/types.ts +42 -0
  63. package/src/test/attachments.test.ts +68 -0
  64. package/src/test/clone.test.ts +373 -0
  65. package/src/test/comments.test.ts +53 -0
  66. package/src/test/config.test.ts +209 -0
  67. package/src/test/confluence-client.test.ts +535 -0
  68. package/src/test/delete.test.ts +39 -0
  69. package/src/test/dependency-sorter.test.ts +384 -0
  70. package/src/test/errors.test.ts +199 -0
  71. package/src/test/file-rename.test.ts +305 -0
  72. package/src/test/file-scanner.test.ts +331 -0
  73. package/src/test/folder-hierarchy.test.ts +337 -0
  74. package/src/test/formatters.test.ts +213 -0
  75. package/src/test/html-converter.test.ts +399 -0
  76. package/src/test/info.test.ts +56 -0
  77. package/src/test/labels.test.ts +70 -0
  78. package/src/test/link-conversion-integration.test.ts +189 -0
  79. package/src/test/link-converter.test.ts +413 -0
  80. package/src/test/link-resolution-pass.test.ts +368 -0
  81. package/src/test/markdown.test.ts +443 -0
  82. package/src/test/mocks/handlers.ts +228 -0
  83. package/src/test/move.test.ts +53 -0
  84. package/src/test/msw-schema-validation.ts +151 -0
  85. package/src/test/page-state.test.ts +542 -0
  86. package/src/test/push.test.ts +551 -0
  87. package/src/test/reference-updater.test.ts +293 -0
  88. package/src/test/resolve-page-target.test.ts +55 -0
  89. package/src/test/search.test.ts +64 -0
  90. package/src/test/setup-msw.ts +75 -0
  91. package/src/test/space-config.test.ts +516 -0
  92. package/src/test/spaces.test.ts +53 -0
  93. package/src/test/sync-engine.test.ts +486 -0
  94. package/src/types/turndown-plugin-gfm.d.ts +9 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Shafovaloff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # cn
2
+
3
+ CLI for syncing Confluence spaces to local markdown.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun install -g @aaronshaf/confluence-cli
9
+ ```
10
+
11
+ ## Getting Started
12
+
13
+ ```bash
14
+ # 1. Configure your Confluence credentials
15
+ cn setup
16
+
17
+ # 2. Clone a Confluence space
18
+ cn clone <SPACE_KEY>
19
+
20
+ # 3. Pull pages as markdown
21
+ cd <SPACE_KEY>
22
+ cn pull
23
+ ```
24
+
25
+ The space key is the identifier in your Confluence URL:
26
+ `https://yoursite.atlassian.net/wiki/spaces/<SPACE_KEY>/...`
27
+
28
+ Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `.confluence.json` in the synced directory.
29
+
30
+ ## Commands
31
+
32
+ | Command | Description |
33
+ |---------|-------------|
34
+ | `cn setup` | Configure Confluence credentials |
35
+ | `cn clone <SPACE_KEY>` | Clone a space to a new folder |
36
+ | `cn pull` | Pull changes from Confluence as markdown |
37
+ | `cn push [file]` | Push local markdown file(s) to Confluence |
38
+ | `cn status` | Check connection and sync status |
39
+ | `cn tree` | Display page hierarchy |
40
+ | `cn open [page]` | Open page in browser |
41
+ | `cn doctor` | Health check for sync issues |
42
+ | `cn search <query>` | Search pages using CQL |
43
+ | `cn spaces` | List available spaces |
44
+ | `cn info <id\|file>` | Show page info and labels |
45
+ | `cn create <title>` | Create a new page |
46
+ | `cn delete <id>` | Delete a page |
47
+ | `cn comments <id\|file>` | Show page comments |
48
+ | `cn labels <id\|file>` | Manage page labels |
49
+ | `cn move <id\|file> <parentId>` | Move a page to a new parent |
50
+ | `cn attachments <id\|file>` | Manage page attachments |
51
+
52
+ Run `cn <command> --help` for details on each command.
53
+
54
+ ## Requirements
55
+
56
+ - Bun 1.2.0+
57
+ - Confluence Cloud account
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ bun install
63
+ bun run cn --help
64
+ bun test
65
+ ```
66
+
67
+ ## License
68
+
69
+ MIT
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@aaronshaf/confluence-cli",
3
+ "version": "0.1.15",
4
+ "description": "Confluence CLI for syncing spaces and local markdown files",
5
+ "type": "module",
6
+ "bin": {
7
+ "cn": "./src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE",
13
+ "package.json"
14
+ ],
15
+ "engines": {
16
+ "bun": ">=1.2.0"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/aaronshaf/confluence-cli.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/aaronshaf/confluence-cli/issues"
27
+ },
28
+ "homepage": "https://github.com/aaronshaf/confluence-cli#readme",
29
+ "scripts": {
30
+ "dev": "bun run --watch src/cli.ts",
31
+ "test": "BUN_TEST_JOBS=1 NODE_ENV=test bun test",
32
+ "test:coverage": "BUN_TEST_JOBS=1 NODE_ENV=test bun test --coverage",
33
+ "test:coverage:report": "BUN_TEST_JOBS=1 NODE_ENV=test bun test --coverage --coverage-reporter=text",
34
+ "test:coverage:check": "BUN_TEST_JOBS=1 NODE_ENV=test bun test --coverage --coverage-threshold=70",
35
+ "check-file-sizes": "bun run scripts/check-file-sizes.ts",
36
+ "lint": "biome check",
37
+ "lint:fix": "biome check --write",
38
+ "format": "biome format --write",
39
+ "typecheck": "tsc --noEmit",
40
+ "cn": "bun run src/cli.ts",
41
+ "prepublishOnly": "bun run lint && bun run typecheck",
42
+ "pre-commit": "bun run typecheck && biome check --write && bun run check-file-sizes && bun run test:coverage:check",
43
+ "prepare": "node scripts/install-hooks.cjs"
44
+ },
45
+ "keywords": [
46
+ "confluence",
47
+ "cli",
48
+ "markdown",
49
+ "sync"
50
+ ],
51
+ "author": "Aaron Shafovaloff <aaronshaf@gmail.com>",
52
+ "license": "MIT",
53
+ "dependencies": {
54
+ "@effect/schema": "^0.75.5",
55
+ "@inquirer/prompts": "^7.8.4",
56
+ "chalk": "^5.4.1",
57
+ "effect": "^3.16.10",
58
+ "gray-matter": "^4.0.3",
59
+ "marked": "17.0.1",
60
+ "ora": "^8.2.0",
61
+ "turndown": "^7.2.0",
62
+ "turndown-plugin-gfm": "^1.0.2",
63
+ "zod": "4.3.5"
64
+ },
65
+ "devDependencies": {
66
+ "@biomejs/biome": "^2.0.6",
67
+ "@types/marked": "6.0.0",
68
+ "@types/turndown": "^5.0.5",
69
+ "bun-types": "^1.0.20",
70
+ "msw": "^2.10.4",
71
+ "typescript": "^5.3.3"
72
+ }
73
+ }
@@ -0,0 +1,113 @@
1
+ import chalk from 'chalk';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
5
+ import { ConfigManager } from '../../lib/config.js';
6
+ import { EXIT_CODES } from '../../lib/errors.js';
7
+ import { escapeXml } from '../../lib/formatters.js';
8
+ import { resolvePageTarget } from '../../lib/resolve-page-target.js';
9
+
10
+ export interface AttachmentsCommandOptions {
11
+ upload?: string;
12
+ download?: string;
13
+ delete?: string;
14
+ xml?: boolean;
15
+ }
16
+
17
+ export async function attachmentsCommand(target: string, options: AttachmentsCommandOptions = {}): Promise<void> {
18
+ const configManager = new ConfigManager();
19
+ const config = await configManager.getConfig();
20
+
21
+ if (!config) {
22
+ console.error(chalk.red('Not configured. Run: cn setup'));
23
+ process.exit(EXIT_CODES.CONFIG_ERROR);
24
+ }
25
+
26
+ const pageId = resolvePageTarget(target);
27
+ const client = new ConfluenceClient(config);
28
+
29
+ if (options.upload) {
30
+ const filePath = options.upload;
31
+ const filename = basename(filePath);
32
+ const data = readFileSync(filePath);
33
+ const mimeType = guessMimeType(filename);
34
+ await client.uploadAttachment(pageId, filename, data, mimeType);
35
+ console.log(`${chalk.green('Uploaded:')} ${filename}`);
36
+ return;
37
+ }
38
+
39
+ if (options.delete) {
40
+ await client.deleteAttachment(options.delete);
41
+ console.log(`${chalk.green('Deleted attachment:')} ${options.delete}`);
42
+ return;
43
+ }
44
+
45
+ const attachments = await client.getAllAttachments(pageId);
46
+
47
+ if (options.download) {
48
+ const attachment = attachments.find((a) => a.id === options.download);
49
+ if (!attachment) {
50
+ console.error(chalk.red(`Attachment not found: ${options.download}`));
51
+ process.exit(EXIT_CODES.GENERAL_ERROR);
52
+ }
53
+ if (!attachment.downloadLink) {
54
+ console.error(chalk.red('No download link available for this attachment.'));
55
+ process.exit(EXIT_CODES.GENERAL_ERROR);
56
+ }
57
+ const buf = await client.downloadAttachment(attachment.downloadLink);
58
+ const safeFilename = basename(attachment.title);
59
+ writeFileSync(safeFilename, buf);
60
+ console.log(`${chalk.green('Downloaded:')} ${safeFilename}`);
61
+ return;
62
+ }
63
+
64
+ if (options.xml) {
65
+ console.log('<attachments>');
66
+ for (const att of attachments) {
67
+ console.log(` <attachment id="${escapeXml(att.id)}">`);
68
+ console.log(` <title>${escapeXml(att.title)}</title>`);
69
+ if (att.mediaType) console.log(` <mediaType>${escapeXml(att.mediaType)}</mediaType>`);
70
+ if (att.fileSize != null) console.log(` <fileSize>${att.fileSize}</fileSize>`);
71
+ console.log(' </attachment>');
72
+ }
73
+ console.log('</attachments>');
74
+ return;
75
+ }
76
+
77
+ if (attachments.length === 0) {
78
+ console.log('No attachments.');
79
+ return;
80
+ }
81
+
82
+ for (const att of attachments) {
83
+ const size = att.fileSize != null ? ` (${formatBytes(att.fileSize)})` : '';
84
+ const mime = att.mediaType ? ` [${att.mediaType}]` : '';
85
+ console.log(`${chalk.bold(att.title)} ${chalk.gray(att.id)}${mime}${size}`);
86
+ }
87
+ }
88
+
89
+ function formatBytes(bytes: number): string {
90
+ if (bytes < 1024) return `${bytes}B`;
91
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
92
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
93
+ }
94
+
95
+ function guessMimeType(filename: string): string {
96
+ const ext = filename.split('.').pop()?.toLowerCase();
97
+ const mimeTypes: Record<string, string> = {
98
+ png: 'image/png',
99
+ jpg: 'image/jpeg',
100
+ jpeg: 'image/jpeg',
101
+ gif: 'image/gif',
102
+ pdf: 'application/pdf',
103
+ txt: 'text/plain',
104
+ md: 'text/markdown',
105
+ json: 'application/json',
106
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
107
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
108
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
109
+ zip: 'application/zip',
110
+ csv: 'text/csv',
111
+ };
112
+ return mimeTypes[ext ?? ''] ?? 'application/octet-stream';
113
+ }
@@ -0,0 +1,188 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { ConfigManager, type Config } from '../../lib/config.js';
6
+ import { EXIT_CODES } from '../../lib/errors.js';
7
+ import { SyncEngine } from '../../lib/sync/index.js';
8
+ import { createProgressReporter } from '../utils/progress-reporter.js';
9
+
10
+ const SEPARATOR = '='.repeat(60);
11
+
12
+ export interface CloneCommandOptions {
13
+ spaceKeys: string[];
14
+ }
15
+
16
+ /**
17
+ * Clone command - clones one or more Confluence spaces to new local directories
18
+ */
19
+ export async function cloneCommand(options: CloneCommandOptions): Promise<void> {
20
+ const configManager = new ConfigManager();
21
+ const config = await configManager.getConfig();
22
+
23
+ if (!config) {
24
+ console.error(chalk.red('Not configured. Please run "cn setup" first.'));
25
+ process.exit(EXIT_CODES.CONFIG_ERROR);
26
+ }
27
+
28
+ // Check for duplicate space keys
29
+ const uniqueKeys = new Set(options.spaceKeys);
30
+ if (uniqueKeys.size !== options.spaceKeys.length) {
31
+ const duplicates = options.spaceKeys.filter((key, index) => options.spaceKeys.indexOf(key) !== index);
32
+ console.error(chalk.red('Duplicate space keys detected.'));
33
+ console.log(chalk.gray(`Duplicates: ${[...new Set(duplicates)].join(', ')}`));
34
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
35
+ }
36
+
37
+ const results: Array<{ spaceKey: string; status: 'success' | 'error'; error?: string }> = [];
38
+
39
+ // Clone each space sequentially
40
+ for (let i = 0; i < options.spaceKeys.length; i++) {
41
+ const spaceKey = options.spaceKeys[i];
42
+ const isMultiSpace = options.spaceKeys.length > 1;
43
+
44
+ if (isMultiSpace) {
45
+ console.log(chalk.blue(`\n${SEPARATOR}`));
46
+ console.log(chalk.blue(`Cloning ${i + 1}/${options.spaceKeys.length}: ${chalk.bold(spaceKey)}`));
47
+ console.log(chalk.blue(SEPARATOR));
48
+ }
49
+
50
+ try {
51
+ await cloneSingleSpace({ spaceKey, directory: spaceKey }, config);
52
+ results.push({ spaceKey, status: 'success' });
53
+ } catch (error) {
54
+ results.push({
55
+ spaceKey,
56
+ status: 'error',
57
+ error: error instanceof Error ? error.message : 'Unknown error',
58
+ });
59
+ }
60
+ }
61
+
62
+ // Check for failures
63
+ const successes = results.filter((r) => r.status === 'success');
64
+ const failures = results.filter((r) => r.status === 'error');
65
+
66
+ // Display summary if multiple spaces were cloned
67
+ if (options.spaceKeys.length > 1) {
68
+ console.log(chalk.blue(`\n${SEPARATOR}`));
69
+ console.log(chalk.bold('Clone Summary'));
70
+ console.log(chalk.blue(SEPARATOR));
71
+
72
+ if (successes.length > 0) {
73
+ console.log(chalk.green(`✓ Successfully cloned: ${successes.map((r) => r.spaceKey).join(', ')}`));
74
+ }
75
+
76
+ if (failures.length > 0) {
77
+ console.log(chalk.red(`✗ Failed to clone: ${failures.map((r) => r.spaceKey).join(', ')}`));
78
+ for (const failure of failures) {
79
+ console.log(chalk.red(` ${failure.spaceKey}: ${failure.error}`));
80
+ }
81
+ }
82
+ }
83
+
84
+ // Exit with error if any failures occurred
85
+ if (failures.length > 0) {
86
+ process.exit(EXIT_CODES.GENERAL_ERROR);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Clone a single space - extracted from original cloneCommand
92
+ */
93
+ async function cloneSingleSpace(options: { spaceKey: string; directory?: string }, config: Config): Promise<void> {
94
+ const syncEngine = new SyncEngine(config);
95
+
96
+ // Determine target directory
97
+ const targetDir = options.directory || options.spaceKey;
98
+ const fullPath = resolve(process.cwd(), targetDir);
99
+
100
+ // Check if directory already exists
101
+ if (existsSync(fullPath)) {
102
+ throw new Error(`Directory "${targetDir}" already exists.`);
103
+ }
104
+
105
+ const spinner = ora({
106
+ text: `Cloning space ${options.spaceKey} into ${targetDir}...`,
107
+ hideCursor: false,
108
+ discardStdin: false,
109
+ }).start();
110
+
111
+ try {
112
+ // Create directory
113
+ mkdirSync(fullPath, { recursive: true });
114
+
115
+ // Initialize space config
116
+ const spaceConfig = await syncEngine.initSync(fullPath, options.spaceKey);
117
+ spinner.succeed(`Cloned space "${spaceConfig.spaceName}" (${spaceConfig.spaceKey}) into ${targetDir}`);
118
+
119
+ // Perform initial pull - wrapped separately so init failures clean up but sync failures don't
120
+ let syncFailed = false;
121
+ try {
122
+ console.log('');
123
+ const progressReporter = createProgressReporter();
124
+ const result = await syncEngine.sync(fullPath, {
125
+ progress: progressReporter,
126
+ });
127
+
128
+ // Show warnings
129
+ if (result.warnings.length > 0) {
130
+ console.log('');
131
+ console.log(chalk.yellow('Warnings:'));
132
+ for (const warning of result.warnings) {
133
+ console.log(chalk.yellow(` ! ${warning}`));
134
+ }
135
+ }
136
+
137
+ // Show errors
138
+ if (result.errors.length > 0) {
139
+ console.log('');
140
+ console.log(chalk.red('Errors:'));
141
+ for (const error of result.errors) {
142
+ console.log(chalk.red(` x ${error}`));
143
+ }
144
+ syncFailed = true;
145
+ }
146
+
147
+ // Final summary
148
+ const { added, modified, deleted } = result.changes;
149
+ const total = added.length + modified.length + deleted.length;
150
+ if (total > 0) {
151
+ console.log('');
152
+ const parts = [];
153
+ if (added.length > 0) parts.push(`${added.length} added`);
154
+ if (modified.length > 0) parts.push(`${modified.length} modified`);
155
+ if (deleted.length > 0) parts.push(`${deleted.length} deleted`);
156
+ console.log(chalk.green(`✓ Clone complete: ${parts.join(', ')}`));
157
+ }
158
+ } catch (_syncError) {
159
+ // Sync failed but clone succeeded - don't clean up, provide recovery guidance
160
+ console.log('');
161
+ console.log(chalk.yellow('Initial pull failed. You can retry with:'));
162
+ syncFailed = true;
163
+ }
164
+
165
+ console.log('');
166
+ console.log(chalk.gray(` cd ${targetDir}`));
167
+ if (syncFailed) {
168
+ console.log(chalk.gray(' cn pull'));
169
+ }
170
+ } catch (error) {
171
+ spinner.fail('Failed to clone space');
172
+
173
+ // Clean up directory on failure (only for init failures, not sync failures)
174
+ if (existsSync(fullPath)) {
175
+ try {
176
+ rmSync(fullPath, { recursive: true });
177
+ } catch {
178
+ // Ignore cleanup errors - directory may be partially created
179
+ }
180
+ }
181
+
182
+ if (error instanceof Error && error.message.includes('not found')) {
183
+ throw new Error(`Space "${options.spaceKey}" not found. Check the space key and try again.`);
184
+ }
185
+
186
+ throw error;
187
+ }
188
+ }
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
3
+ import { ConfigManager } from '../../lib/config.js';
4
+ import { EXIT_CODES } from '../../lib/errors.js';
5
+ import { escapeXml } from '../../lib/formatters.js';
6
+ import { resolvePageTarget } from '../../lib/resolve-page-target.js';
7
+
8
+ export interface CommentsCommandOptions {
9
+ xml?: boolean;
10
+ }
11
+
12
+ function stripHtml(html: string): string {
13
+ return html.replace(/<[^>]+>/g, '').trim();
14
+ }
15
+
16
+ export async function commentsCommand(target: string, options: CommentsCommandOptions = {}): Promise<void> {
17
+ const configManager = new ConfigManager();
18
+ const config = await configManager.getConfig();
19
+
20
+ if (!config) {
21
+ console.error(chalk.red('Not configured. Run: cn setup'));
22
+ process.exit(EXIT_CODES.CONFIG_ERROR);
23
+ }
24
+
25
+ const pageId = resolvePageTarget(target);
26
+ const client = new ConfluenceClient(config);
27
+ const comments = await client.getAllFooterComments(pageId);
28
+
29
+ if (options.xml) {
30
+ console.log('<comments>');
31
+ for (const comment of comments) {
32
+ const body = comment.body?.storage?.value ? stripHtml(comment.body.storage.value) : '';
33
+ console.log(` <comment id="${escapeXml(comment.id)}">`);
34
+ if (body) console.log(` <body>${escapeXml(body)}</body>`);
35
+ if (comment.authorId) console.log(` <authorId>${escapeXml(comment.authorId)}</authorId>`);
36
+ if (comment.createdAt) console.log(` <createdAt>${escapeXml(comment.createdAt)}</createdAt>`);
37
+ console.log(' </comment>');
38
+ }
39
+ console.log('</comments>');
40
+ return;
41
+ }
42
+
43
+ if (comments.length === 0) {
44
+ console.log('No comments found.');
45
+ return;
46
+ }
47
+
48
+ for (const comment of comments) {
49
+ const body = comment.body?.storage?.value ? stripHtml(comment.body.storage.value) : '';
50
+ console.log(chalk.gray(`--- ${comment.id} ---`));
51
+ if (body) console.log(body);
52
+ if (comment.authorId) console.log(chalk.gray(`Author: ${comment.authorId}`));
53
+ if (comment.createdAt) console.log(chalk.gray(`Date: ${comment.createdAt}`));
54
+ console.log();
55
+ }
56
+ }
@@ -0,0 +1,58 @@
1
+ import chalk from 'chalk';
2
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
3
+ import { ConfigManager } from '../../lib/config.js';
4
+ import { EXIT_CODES } from '../../lib/errors.js';
5
+ import { readSpaceConfig } from '../../lib/space-config.js';
6
+ import { openUrl } from '../utils/browser.js';
7
+
8
+ export interface CreateCommandOptions {
9
+ space?: string;
10
+ parent?: string;
11
+ open?: boolean;
12
+ }
13
+
14
+ export async function createCommand(title: string, options: CreateCommandOptions = {}): Promise<void> {
15
+ const configManager = new ConfigManager();
16
+ const config = await configManager.getConfig();
17
+
18
+ if (!config) {
19
+ console.error(chalk.red('Not configured. Run: cn setup'));
20
+ process.exit(EXIT_CODES.CONFIG_ERROR);
21
+ }
22
+
23
+ const client = new ConfluenceClient(config);
24
+ let spaceId: string | undefined;
25
+
26
+ if (options.space) {
27
+ const space = await client.getSpaceByKey(options.space);
28
+ spaceId = space.id;
29
+ } else {
30
+ const spaceConfig = readSpaceConfig(process.cwd());
31
+ if (!spaceConfig) {
32
+ console.error(chalk.red('Not in a cloned space directory. Use --space to specify a space key.'));
33
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
34
+ }
35
+ spaceId = spaceConfig.spaceId;
36
+ }
37
+
38
+ const page = await client.createPage({
39
+ spaceId,
40
+ status: 'current',
41
+ title,
42
+ parentId: options.parent,
43
+ body: {
44
+ representation: 'storage',
45
+ value: '',
46
+ },
47
+ });
48
+
49
+ console.log(`${chalk.green('Created:')} ${chalk.bold(page.title)} ${chalk.gray(page.id)}`);
50
+ if (page._links?.webui) {
51
+ const url = `${config.confluenceUrl}/wiki${page._links.webui}`;
52
+ console.log(`URL: ${chalk.blue(url)}`);
53
+
54
+ if (options.open) {
55
+ openUrl(url);
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,46 @@
1
+ import chalk from 'chalk';
2
+ import { confirm } from '@inquirer/prompts';
3
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
4
+ import { ConfigManager } from '../../lib/config.js';
5
+ import { EXIT_CODES } from '../../lib/errors.js';
6
+ import { resolvePageTarget } from '../../lib/resolve-page-target.js';
7
+
8
+ export interface DeleteCommandOptions {
9
+ force?: boolean;
10
+ }
11
+
12
+ export async function deleteCommand(target: string, options: DeleteCommandOptions = {}): Promise<void> {
13
+ const configManager = new ConfigManager();
14
+ const config = await configManager.getConfig();
15
+
16
+ if (!config) {
17
+ console.error(chalk.red('Not configured. Run: cn setup'));
18
+ process.exit(EXIT_CODES.CONFIG_ERROR);
19
+ }
20
+
21
+ const pageId = resolvePageTarget(target);
22
+ const client = new ConfluenceClient(config);
23
+
24
+ if (!options.force) {
25
+ let page: Awaited<ReturnType<typeof client.getPage>>;
26
+ try {
27
+ page = await client.getPage(pageId, false);
28
+ } catch {
29
+ console.error(chalk.red(`Page not found: ${pageId}`));
30
+ process.exit(EXIT_CODES.PAGE_NOT_FOUND);
31
+ }
32
+
33
+ const confirmed = await confirm({
34
+ message: `Delete "${page.title}" (${pageId})?`,
35
+ default: false,
36
+ });
37
+
38
+ if (!confirmed) {
39
+ console.log('Cancelled.');
40
+ return;
41
+ }
42
+ }
43
+
44
+ await client.deletePage(pageId);
45
+ console.log(`${chalk.green('Deleted:')} ${pageId}`);
46
+ }